// by mircemk, September 2026 #include #include #include #include #include #include "driver/i2s.h" TFT_eSPI tft = TFT_eSPI(); Preferences preferences; // ===================================================== // NON-VOLATILE SETTINGS MEMORY // ===================================================== bool settingsDirty = false; unsigned long lastSettingsChange = 0; const unsigned long SETTINGS_SAVE_DELAY = 1000; // ===================================================== // GRAPH SPRITE // ===================================================== TFT_eSprite graphSprite = TFT_eSprite(&tft); // ===================================================== // ENCODER PINS // ===================================================== // Encoder 1 - FREQUENCY #define ENC1_A 34 #define ENC1_B 35 #define ENC1_SW 32 // Encoder 2 - GAIN #define ENC2_A 36 #define ENC2_B 39 #define ENC2_SW 33 // Encoder 3 - Q FACTOR #define ENC3_A 25 #define ENC3_B 26 #define ENC3_SW 27 // If direction is reversed, // change 1 to -1 #define ENC1_DIR -1 #define ENC2_DIR -1 #define ENC3_DIR -1 // ===================================================== // BAND DATA // ===================================================== int selectedBand = 1; // Separate Frequency / Gain / Q for every band float bandFreq[5] = { 80.0, 250.0, 1250.0, 5000.0, 12000.0 }; float bandGain[5] = { 0.0, 0.0, 0.0, 0.0, 0.0 }; float bandQ[5] = { 1.00, 1.00, 1.40, 1.00, 1.00 }; // ===================================================== // REAL AUDIO ENGINE - WM8960 + 5-BAND STEREO DSP // ===================================================== // // Proven hardware / clock configuration: // ESP32 classic WROOM-32 // ESP32 = I2S MASTER // WM8960 = I2S SLAVE / PERIPHERAL // 48 kHz / 16-bit stereo // I2S_NUM_1 // // IMPORTANT STARTUP RULE: // WM8960 is configured completely FIRST. // ESP32 I2S clocks are started LAST. // // LINE IN -> WM8960 ADC -> TXSDA -> ESP32 // -> 5 real peaking biquads per channel // -> RXSDA -> WM8960 DAC -> output // ===================================================== WM8960 codec; // ---------- WM8960 I2C ---------- #define WM_SDA 21 #define WM_SCL 22 // ---------- WM8960 I2S ---------- #define I2S_BCLK 14 #define I2S_WS 13 #define I2S_DOUT 17 // ESP32 -> WM8960 RXSDA (DAC data) #define I2S_DIN 19 // WM8960 TXSDA -> ESP32 (ADC data) #define AUDIO_I2S I2S_NUM_1 const uint32_t AUDIO_SAMPLE_RATE = 48000; const int AUDIO_FRAMES = 240; // 240 stereo frames = 960 bytes per audio block int16_t audioBuffer[AUDIO_FRAMES * 2]; // No fixed digital attenuation in this integrated version. // Later this can become a user/preamp/headroom control if needed. const float DSP_PREAMP_GAIN = 1.0f; // 0 dB // ===================================================== // TEMPORARY CLIPPING-DEMO INPUT SENSITIVITY // ===================================================== // TEST ONLY: +18 dB PGA gain makes it easy to provoke DSP clipping // with a normal LINE IN source and demonstrate the red graph warning. // For normal/final operation set this back to 0.0f if desired. // SparkFun WM8960 library supports PGA gain from -17.25 dB to +30 dB. const float LINE_INPUT_PGA_DB = 0.0f; struct BiquadCoeff { float b0; float b1; float b2; float a1; float a2; }; struct BiquadState { float z1; float z2; }; // Same coefficients for L/R; independent states preserve stereo. BiquadCoeff dspCoeff[5]; BiquadState dspStateCH0[5]; BiquadState dspStateCH1[5]; // UI -> audio task handoff. // The audio task owns dspCoeff[] and filter states. portMUX_TYPE dspSettingsMux = portMUX_INITIALIZER_UNLOCKED; float pendingBandFreq[5]; float pendingBandGain[5]; float pendingBandQ[5]; volatile bool dspSettingsDirty = true; volatile bool dspResetRequested = true; volatile bool dspBypassMode = false; TaskHandle_t audioTaskHandle = NULL; // DSP output clipping latch. The audio task extends this timestamp // whenever either processed stereo channel exceeds full scale. // The main UI uses it to turn the graph red for 500 ms. volatile unsigned long dspClipUntil = 0; // Tracks whether the graph is currently being shown in CLIP-red mode, // so redraw happens only on CLIP state transitions. bool clipGraphShown = false; // ===================================================== // FREQUENCY RANGE FOR EACH BAND // Overlapping ranges // ===================================================== const float BAND_FREQ_MIN[5] = { 20.0, // BAND 1 80.0, // BAND 2 250.0, // BAND 3 1000.0, // BAND 4 4000.0 // BAND 5 }; const float BAND_FREQ_MAX[5] = { 250.0, // BAND 1 1000.0, // BAND 2 4000.0, // BAND 3 10000.0, // BAND 4 20000.0 // BAND 5 }; // ===================================================== // MODES // ===================================================== // SW2 bool speed2x = false; // SW3 bool allBandsView = false; // ===================================================== // 5 USER PRESETS - SW3 LONG PRESS MODE // ===================================================== // NORMAL MODE: // Q encoder rotation = Q // SW3 short = SINGLE / ALL graph // SW3 long = enter PRESET MODE // // PRESET MODE: // Q encoder rotation = select P1..P5 // SW3 short = LOAD selected preset // SW3 long = SAVE current EQ to selected preset // // Presets store ONLY the 15 sound parameters: // Frequency[5], Gain[5], Q[5] // UI states (BYPASS, speed, ALL view, selected band) are not preset data. // ===================================================== const int PRESET_COUNT = 5; const unsigned long SW3_LONG_PRESS_MS = 2000; const unsigned long SW3_DEBOUNCE_MS = 30; const unsigned long PRESET_MODE_TIMEOUT_MS = 8000; const unsigned long PRESET_STATUS_MS = 1000; bool presetMode = false; int presetSelectedSlot = 1; int lastPresetSlot = 1; unsigned long presetLastActivity = 0; String presetStatusText = ""; unsigned long presetStatusUntil = 0; // SW1 LONG PRESS (>2 sec) // true = EQ bypass / flat 0 dB response bool bypassMode = false; // Long-press timing for SW1 const unsigned long SW1_LONG_PRESS_MS = 2000; const unsigned long SW1_DEBOUNCE_MS = 30; // Long-press timing for SW2 const unsigned long SW2_LONG_PRESS_MS = 2000; const unsigned long SW2_DEBOUNCE_MS = 30; // ===================================================== // COLOR DEFINITIONS // EXACTLY FROM YOUR GRAPHIC FINAL CODE // ===================================================== #define COL_BG 0x0000 #define COL_BUTTON 0x0000 #define COL_GRAPH_BG 0x0000 #define COL_GRID 0x8400 #define COL_BORDER 0x3D9F #define COL_BORDER_G 0xFFFF #define COL_CYAN 0x07FF #define COL_YELLOW 0xFFE0 #define COL_GREEN 0x07E0 #define COL_MAGENTA 0xF81F #define COL_RED 0xF800 #define COL_WHITE 0xFFFF #define COL_GREY 0x9E79 // Current transient PRESET status color (LOADED / SAVED / EMPTY / CANCEL). uint16_t presetStatusColor = COL_CYAN; // ===================================================== // LAYOUT DIMENSIONS // EXACTLY FROM YOUR GRAPHIC FINAL CODE // ===================================================== const int TITLE_Y = 5; // ---------- LEFT PANEL ---------- const int PANEL_X = 6; const int PANEL_Y = 30; const int PANEL_W = 66; const int PANEL_H = 145; // ---------- GRAPH ---------- const int GRAPH_X = 98; const int GRAPH_Y = 30; const int GRAPH_W = 214; const int GRAPH_H = 145; // ---------- GRAPH RANGE ---------- const float F_MIN = 20.0; const float F_MAX = 20000.0; const float DB_MIN = -15.0; const float DB_MAX = 15.0; // ===================================================== // ENCODER STRUCTURE // ===================================================== struct EncoderState { uint8_t pinA; uint8_t pinB; uint8_t pinSW; uint8_t lastAB; int8_t accumulator; bool lastButton; unsigned long lastButtonTime; }; EncoderState enc1 = { ENC1_A, ENC1_B, ENC1_SW, 0, 0, HIGH, 0 }; EncoderState enc2 = { ENC2_A, ENC2_B, ENC2_SW, 0, 0, HIGH, 0 }; EncoderState enc3 = { ENC3_A, ENC3_B, ENC3_SW, 0, 0, HIGH, 0 }; // ===================================================== // ROTARY TRANSITION TABLE // ===================================================== const int8_t encoderTable[16] = { 0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0 }; // ===================================================== // BASIC HELPERS // ===================================================== int currentBandIndex() { return selectedBand - 1; } // ===================================================== // MAPPING // ===================================================== int freqToX(float f) { float p = (log10(f) - log10(F_MIN)) / (log10(F_MAX) - log10(F_MIN)); return GRAPH_X + (int)(p * (GRAPH_W - 1)); } int dbToY(float db) { if (db > DB_MAX) db = DB_MAX; if (db < DB_MIN) db = DB_MIN; float p = (DB_MAX - db) / (DB_MAX - DB_MIN); return GRAPH_Y + (int)(p * (GRAPH_H - 1)); } // ===================================================== // LOCAL SPRITE COORDINATES // ===================================================== int freqToLocalX(float f) { return freqToX(f) - GRAPH_X; } int dbToLocalY(float db) { return dbToY(db) - GRAPH_Y; } // ===================================================== // CLIPPING INDICATOR STATE // ===================================================== bool isDSPClippingActive() { // BYPASS is true digital pass-through, so DSP clipping indication // is intentionally suppressed while BYPASS is active. if (bypassMode) { return false; } unsigned long until = dspClipUntil; unsigned long now = millis(); // Wrap-safe timestamp comparison. return ((long)(until - now) > 0); } // ===================================================== // ONE BAND VISUAL CURVE // ===================================================== float visualCurveBand(int band, float f) { float freqHz = bandFreq[band]; float gainDb = bandGain[band]; float qFactor = bandQ[band]; float d = log(f / freqHz); float width = 0.62 / qFactor; return gainDb * exp( -(d * d) / (2.0 * width * width) ); } // ===================================================== // DISPLAYED CURVE // // SINGLE mode: // selected band only // // ALL mode: // dB responses of all 5 bands are added // ===================================================== float visualCurve(float f) { // BYPASS: // Keep all stored Frequency / Gain / Q values unchanged, // but display a flat 0 dB response. // Later the real audio DSP will use the same bypassMode // to pass audio without EQ processing. if (bypassMode) { return 0.0; } if (!allBandsView) { return visualCurveBand( currentBandIndex(), f ); } float totalDb = 0.0; for (int i = 0; i < 5; i++) { totalDb += visualCurveBand( i, f ); } return totalDb; } // ===================================================== // VALUE FORMATTING // ===================================================== String formatFrequency(float f) { if (f < 1000.0) { return String( (int)round(f) ) + " Hz"; } if (f < 10000.0) { return String( f / 1000.0, 2 ) + "kHz"; } return String( f / 1000.0, 1 ) + "kHz"; } String formatGain(float gain) { String s = ""; if (gain > 0.0) s += "+"; if (fabs(gain) >= 10.0) s += String(gain, 0); else s += String(gain, 1); s += " dB"; return s; } // ===================================================== // HEADER // UNCHANGED GRAPHICS // ===================================================== void drawHeader() { tft.setTextDatum(TC_DATUM); tft.setTextColor( COL_CYAN, COL_BG ); tft.drawString( "5-BAND PARAMETRIC EQUALIZER", 160, TITLE_Y, 2 ); tft.drawFastHLine( 10, TITLE_Y + 7, 40, COL_CYAN ); tft.drawFastHLine( 270, TITLE_Y + 7, 40, COL_CYAN ); } // ===================================================== // LEFT PANEL // SAME GRAPHICS - DYNAMIC VALUES // ===================================================== void drawLeftPanel() { tft.fillRoundRect( PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 6, COL_BG ); tft.drawRoundRect( PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 6, COL_BORDER ); int x = PANEL_X + 6; int b = currentBandIndex(); tft.setTextDatum( TL_DATUM ); // BAND tft.setTextColor( COL_YELLOW, COL_BG ); tft.drawString( "BAND " + String(selectedBand), x, PANEL_Y + 5, 2 ); // FREQ tft.setTextColor( COL_WHITE, COL_BG ); tft.drawString( "FREQ", x, PANEL_Y + 33, 1 ); tft.setTextColor( COL_CYAN, COL_BG ); tft.drawString( formatFrequency( bandFreq[b] ), x, PANEL_Y + 45, 2 ); // GAIN tft.setTextColor( COL_WHITE, COL_BG ); tft.drawString( "GAIN", x, PANEL_Y + 71, 1 ); tft.setTextColor( COL_GREEN, COL_BG ); tft.drawString( formatGain( bandGain[b] ), x, PANEL_Y + 83, 2 ); // Q FACTOR tft.setTextColor( COL_WHITE, COL_BG ); tft.drawString( "Q FACTOR", x, PANEL_Y + 109, 1 ); tft.setTextColor( COL_MAGENTA, COL_BG ); tft.drawString( String( bandQ[b], 2 ), x, PANEL_Y + 121, 2 ); } // ===================================================== // SPEED INDICATOR // // Small white 1x / 2x directly below left panel // ===================================================== void drawSpeedIndicator() { // Clear indicator area tft.fillRect( PANEL_X, PANEL_Y + PANEL_H + 1, PANEL_W, 14, COL_BG ); int indicatorY = PANEL_Y + PANEL_H + 7; // --------------------------------------------- // B = BYPASS MODE ACTIVE // --------------------------------------------- if (bypassMode) { tft.setTextDatum(TC_DATUM); tft.setTextColor( COL_WHITE, COL_BG ); tft.drawString( "B", PANEL_X + 9, PANEL_Y + PANEL_H + 3, 1 ); } // --------------------------------------------- // RED DOT = ALL BANDS VIEW ACTIVE // --------------------------------------------- if (allBandsView) { tft.fillCircle( PANEL_X + 21, indicatorY, 2, TFT_RED ); } // --------------------------------------------- // 1x / 2x // --------------------------------------------- tft.setTextDatum(TC_DATUM); tft.setTextColor( COL_WHITE, COL_BG ); tft.drawString( speed2x ? "2x" : "1x", PANEL_X + 43, PANEL_Y + PANEL_H + 3, 1 ); } // ===================================================== // STATIC GRAPH LABELS // // These do NOT need redrawing during encoder movement // ===================================================== void drawGraphLabels() { // ---------- dB labels ---------- int dbValues[] = { 15, 10, 5, 0, -5, -10, -15 }; for (int i = 0; i < 7; i++) { int y = dbToY( dbValues[i] ); tft.setTextDatum( MR_DATUM ); tft.setTextColor( (dbValues[i] == 0) ? COL_WHITE : COL_GREY, COL_BG ); String s = (dbValues[i] > 0) ? "+" + String(dbValues[i]) : String(dbValues[i]); tft.drawString( s, GRAPH_X - 4, y, 1 ); } // ---------- Frequency labels ---------- const float fMarks[] = { 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000 }; const char* fLabels[] = { "20", "50", "100", "200", "500", "1k", "2k", "5k", "10k", "20k" }; for (int i = 0; i < 10; i++) { int x = freqToX( fMarks[i] ); tft.setTextColor( COL_GREY, COL_BG ); if (i == 3) { tft.setTextDatum( TC_DATUM ); tft.drawString( fLabels[i], x + 2, GRAPH_Y + GRAPH_H + 3, 1 ); } else if (i == 8) { tft.setTextDatum( TC_DATUM ); tft.drawString( fLabels[i], x - 1, GRAPH_Y + GRAPH_H + 3, 1 ); } else if (i == 9) { tft.setTextDatum( TR_DATUM ); tft.drawString( fLabels[i], x + 4, GRAPH_Y + GRAPH_H + 3, 1 ); } else if (i == 0) { tft.setTextDatum( TL_DATUM ); tft.drawString( fLabels[i], x, GRAPH_Y + GRAPH_H + 3, 1 ); } else { tft.setTextDatum( TC_DATUM ); tft.drawString( fLabels[i], x, GRAPH_Y + GRAPH_H + 3, 1 ); } } } // ===================================================== // PRESET STATUS OVERLAY INSIDE GRAPH // Top-center, intentionally small and unobtrusive. // ===================================================== bool presetStatusActive() { if (presetStatusText.length() == 0) return false; return ((long)(presetStatusUntil - millis()) > 0); } void setPresetStatus( const String &text, uint16_t color ) { presetStatusText = text; presetStatusColor = color; presetStatusUntil = millis() + PRESET_STATUS_MS; } void clearPresetStatus() { presetStatusText = ""; presetStatusUntil = 0; } void drawPresetOverlayOnSprite() { String text = ""; uint16_t textColor = COL_CYAN; // A transient result message has priority over PRESET selection text. if (presetStatusActive()) { text = presetStatusText; textColor = presetStatusColor; } else if (presetMode) { text = String("PRESET P") + String(presetSelectedSlot); textColor = COL_CYAN; } else { return; } // Fixed compact black patch prevents the EQ curve/grid from // making the status difficult to read. const int boxW = 96; const int boxH = 14; const int boxX = (GRAPH_W - boxW) / 2; const int boxY = 3; graphSprite.fillRoundRect( boxX, boxY, boxW, boxH, 3, COL_GRAPH_BG ); graphSprite.setTextDatum(TC_DATUM); graphSprite.setTextColor( textColor, COL_GRAPH_BG ); graphSprite.drawString( text, GRAPH_W / 2, boxY + 3, 1 ); } // ===================================================== // GRAPH RENDERING INTO RAM SPRITE // ===================================================== void renderGraph() { graphSprite.fillSprite( COL_GRAPH_BG ); // During a latched DSP clipping event only the graph rendering changes: // cyan curve/fill -> red curve/fill. Layout and all controls stay untouched. bool clipActive = isDSPClippingActive(); uint16_t graphLineColor = clipActive ? COL_RED : COL_CYAN; const int bottomY = GRAPH_H - 2; // =================================================== // CURVE + SAME GRADIENT // =================================================== int oldX = 1; int oldY = dbToLocalY( visualCurve(F_MIN) ); for ( int x = 1; x < GRAPH_W - 1; x++ ) { float p = (float)x / (float)(GRAPH_W - 1); float logF = log10(F_MIN) + p * ( log10(F_MAX) - log10(F_MIN) ); float f = pow( 10.0, logF ); float db = visualCurve(f); int y = dbToLocalY(db); // ----------------------------------------------- // SAME GRADIENT AS GRAPHIC FINAL // ----------------------------------------------- if (y < bottomY) { int fillHeight = bottomY - y; for ( int h = 1; h < fillHeight; h++ ) { float ratio = 1.0 - ( (float)h / (float)fillHeight ); uint8_t val = (uint8_t) ( ratio * 128.0 ); uint16_t gradColor; if (clipActive) { // Red clipping gradient gradColor = tft.color565( val, 0, 0 ); } else { // Original cyan gradient gradColor = tft.color565( 0, val, val ); } graphSprite.drawPixel( x, y + h, gradColor ); } } // Main cyan line graphSprite.drawLine( oldX, oldY, x, y, graphLineColor ); // Second pixel line graphSprite.drawLine( oldX, oldY + 1, x, y + 1, graphLineColor ); oldX = x; oldY = y; } // =================================================== // dB GRID // =================================================== int dbValues[] = { 15, 10, 5, 0, -5, -10, -15 }; for (int i = 0; i < 7; i++) { int y = dbToLocalY( dbValues[i] ); for ( int px = 1; px < GRAPH_W - 1; px += 3 ) { graphSprite.drawPixel( px, y, COL_GRID ); } } // =================================================== // FREQUENCY GRID // =================================================== const float fMarks[] = { 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000 }; for (int i = 0; i < 10; i++) { int x = freqToLocalX( fMarks[i] ); for ( int py = 1; py < GRAPH_H - 1; py += 3 ) { graphSprite.drawPixel( x, py, COL_GRID ); } } // =================================================== // WHITE BORDER // =================================================== graphSprite.drawRect( 0, 0, GRAPH_W, GRAPH_H, clipActive ? COL_RED : COL_BORDER_G ); // =================================================== // PRESET STATUS / SELECTION OVERLAY // =================================================== drawPresetOverlayOnSprite(); // =================================================== // PUSH COMPLETE GRAPH AT ONCE // =================================================== graphSprite.pushSprite( GRAPH_X, GRAPH_Y ); } // ===================================================== // MINI CURVES // UNCHANGED // ===================================================== void drawMiniCurve( int x, int y, uint16_t color ) { int oldX = x; int oldY = y + 8; for (int i = 0; i < 38; i++) { float d = ((float)i - 19.0) / 5.5; float response = exp( -(d * d) / 2.0 ); int py = y + 8 - (int)( response * 6.0 ); tft.drawLine( oldX, oldY, x + i, py, color ); oldX = x + i; oldY = py; } } // ===================================================== // BAND BUTTONS // UNCHANGED GRAPHICS // ===================================================== void drawBandButtons() { const int y = 193; const int h = 40; const int w = 58; const int gap = 4; for (int i = 0; i < 5; i++) { int x = 6 + i * (w + gap); bool active = (i + 1 == selectedBand); uint16_t bg = COL_BG; uint16_t border = active ? COL_YELLOW : COL_BORDER; uint16_t text = active ? COL_YELLOW : COL_WHITE; uint16_t curve = active ? COL_YELLOW : COL_CYAN; tft.fillRoundRect( x, y, w, h, 6, bg ); tft.drawRoundRect( x, y, w, h, 6, border ); drawMiniCurve( x + 10, y + 4, curve ); tft.setTextDatum( TC_DATUM ); tft.setTextColor( text, bg ); String s = "BAND " + String(i + 1); tft.drawString( s, x + w / 2, y + 24, 1 ); } } // ===================================================== // ENCODER READ // ===================================================== int readEncoder( EncoderState &enc ) { uint8_t currentAB = ( digitalRead(enc.pinA) << 1 ) | digitalRead(enc.pinB); uint8_t index = ( enc.lastAB << 2 ) | currentAB; int8_t movement = encoderTable[index]; enc.lastAB = currentAB; enc.accumulator += movement; // Four transitions per detent if (enc.accumulator >= 4) { enc.accumulator = 0; return +1; } if (enc.accumulator <= -4) { enc.accumulator = 0; return -1; } return 0; } // ===================================================== // BUTTON DEBOUNCE // ===================================================== bool buttonPressed( EncoderState &enc ) { bool state = digitalRead( enc.pinSW ); bool pressed = false; unsigned long now = millis(); if ( enc.lastButton == HIGH && state == LOW && now - enc.lastButtonTime > 160 ) { pressed = true; enc.lastButtonTime = now; } enc.lastButton = state; return pressed; } // ===================================================== // SPEED MULTIPLIER // ===================================================== int speedMultiplier() { return speed2x ? 2 : 1; } // ===================================================== /// ===================================================== // FREQUENCY CHANGE // ===================================================== void changeFrequency(int direction) { int b = currentBandIndex(); int mult = speedMultiplier(); // 1x = 1/48 octave // 2x = 1/24 octave float factor = pow( 2.0, ((float)direction * mult) / 48.0 ); bandFreq[b] *= factor; if (bandFreq[b] < BAND_FREQ_MIN[b]) bandFreq[b] = BAND_FREQ_MIN[b]; if (bandFreq[b] > BAND_FREQ_MAX[b]) bandFreq[b] = BAND_FREQ_MAX[b]; } // ===================================================== // GAIN CHANGE // ===================================================== void changeGain(int direction) { int b = currentBandIndex(); // 1x = 0.25 dB // 2x = 0.50 dB bandGain[b] += direction * 0.25 * speedMultiplier(); if (bandGain[b] > 15.0) bandGain[b] = 15.0; if (bandGain[b] < -15.0) bandGain[b] = -15.0; } // ===================================================== // Q CHANGE // ===================================================== void changeQ(int direction) { int b = currentBandIndex(); // 1x = 0.05 // 2x = 0.10 bandQ[b] += direction * 0.05 * speedMultiplier(); if (bandQ[b] > 10.0) bandQ[b] = 10.0; if (bandQ[b] < 0.20) bandQ[b] = 0.20; } // ===================================================== // NEXT BAND // ===================================================== void nextBand() { selectedBand++; if (selectedBand > 5) selectedBand = 1; } // ===================================================== // 5 PRESET SLOTS IN NVS // ===================================================== const uint32_t PRESET_MAGIC = 0x45515031UL; // "EQP1" marker struct EQPresetStorage { uint32_t magic; float freq[5]; float gain[5]; float q[5]; }; void makePresetKey(int slot, char *key, size_t keySize) { snprintf( key, keySize, "p%d", slot ); } bool savePresetSlot(int slot) { if (slot < 1 || slot > PRESET_COUNT) return false; EQPresetStorage data; data.magic = PRESET_MAGIC; for (int i = 0; i < 5; i++) { data.freq[i] = bandFreq[i]; data.gain[i] = bandGain[i]; data.q[i] = bandQ[i]; } char key[8]; makePresetKey(slot, key, sizeof(key)); preferences.begin("peqpres", false); size_t written = preferences.putBytes( key, &data, sizeof(data) ); preferences.end(); return (written == sizeof(data)); } bool loadPresetSlot(int slot) { if (slot < 1 || slot > PRESET_COUNT) return false; char key[8]; makePresetKey(slot, key, sizeof(key)); EQPresetStorage data; preferences.begin("peqpres", true); size_t storedLength = preferences.getBytesLength(key); if (storedLength != sizeof(data)) { preferences.end(); return false; } size_t readLength = preferences.getBytes( key, &data, sizeof(data) ); preferences.end(); if ( readLength != sizeof(data) || data.magic != PRESET_MAGIC ) { return false; } for (int i = 0; i < 5; i++) { bandFreq[i] = data.freq[i]; bandGain[i] = data.gain[i]; bandQ[i] = data.q[i]; // Defensive validation in case NVS ever contains old/corrupt data. if (bandFreq[i] < BAND_FREQ_MIN[i]) bandFreq[i] = BAND_FREQ_MIN[i]; if (bandFreq[i] > BAND_FREQ_MAX[i]) bandFreq[i] = BAND_FREQ_MAX[i]; if (bandGain[i] < -15.0f) bandGain[i] = -15.0f; if (bandGain[i] > 15.0f) bandGain[i] = 15.0f; if (bandQ[i] < 0.20f) bandQ[i] = 0.20f; if (bandQ[i] > 10.0f) bandQ[i] = 10.0f; } return true; } // ===================================================== // SAVE / LOAD SETTINGS // Added only for power-off memory // ===================================================== void saveSettings() { preferences.begin("peq", false); preferences.putBytes( "freq", bandFreq, sizeof(bandFreq) ); preferences.putBytes( "gain", bandGain, sizeof(bandGain) ); preferences.putBytes( "q", bandQ, sizeof(bandQ) ); preferences.putInt( "band", selectedBand ); preferences.putBool( "speed2x", speed2x ); preferences.putBool( "allBands", allBandsView ); preferences.putBool( "bypass", bypassMode ); preferences.putInt( "lastPreset", lastPresetSlot ); preferences.putBool( "valid", true ); preferences.end(); } void loadSettings() { preferences.begin("peq", true); bool valid = preferences.getBool( "valid", false ); if (valid) { if ( preferences.getBytesLength("freq") == sizeof(bandFreq) ) { preferences.getBytes( "freq", bandFreq, sizeof(bandFreq) ); } if ( preferences.getBytesLength("gain") == sizeof(bandGain) ) { preferences.getBytes( "gain", bandGain, sizeof(bandGain) ); } if ( preferences.getBytesLength("q") == sizeof(bandQ) ) { preferences.getBytes( "q", bandQ, sizeof(bandQ) ); } selectedBand = preferences.getInt( "band", selectedBand ); if ( selectedBand < 1 || selectedBand > 5 ) { selectedBand = 1; } speed2x = preferences.getBool( "speed2x", speed2x ); allBandsView = preferences.getBool( "allBands", allBandsView ); bypassMode = preferences.getBool( "bypass", bypassMode ); lastPresetSlot = preferences.getInt( "lastPreset", lastPresetSlot ); if ( lastPresetSlot < 1 || lastPresetSlot > PRESET_COUNT ) { lastPresetSlot = 1; } } preferences.end(); presetSelectedSlot = lastPresetSlot; // Ensure previously saved frequencies also respect // the new per-band overlapping limits. for (int i = 0; i < 5; i++) { if (bandFreq[i] < BAND_FREQ_MIN[i]) bandFreq[i] = BAND_FREQ_MIN[i]; if (bandFreq[i] > BAND_FREQ_MAX[i]) bandFreq[i] = BAND_FREQ_MAX[i]; } } void markSettingsChanged() { settingsDirty = true; lastSettingsChange = millis(); } void saveSettingsIfNeeded() { if ( settingsDirty && millis() - lastSettingsChange >= SETTINGS_SAVE_DELAY ) { saveSettings(); settingsDirty = false; } } // ===================================================== // SW1 SHORT / LONG PRESS HANDLER // // Short press : next BAND // Long press : BYPASS ON / OFF // // The long press does NOT overwrite Frequency / Gain / Q. // It only forces a flat 0 dB response, so a second long // press restores the exact previous EQ settings. // ===================================================== void handleSW1( bool &bandChanged, bool &bypassChanged ) { static bool lastRawState = HIGH; static bool stableState = HIGH; static unsigned long lastRawChange = 0; static unsigned long pressStart = 0; static bool longPressHandled = false; unsigned long now = millis(); bool rawState = digitalRead(ENC1_SW); // Raw-state change -> start debounce timer if (rawState != lastRawState) { lastRawState = rawState; lastRawChange = now; } // Accept a new stable state only after debounce time if ( (now - lastRawChange >= SW1_DEBOUNCE_MS) && (stableState != rawState) ) { stableState = rawState; // Button became pressed if (stableState == LOW) { pressStart = now; longPressHandled = false; } // Button became released else { // If no long press was executed, this was a short press if (!longPressHandled) { nextBand(); bandChanged = true; markSettingsChanged(); } } } // Long press while the button is still held if ( stableState == LOW && !longPressHandled && (now - pressStart >= SW1_LONG_PRESS_MS) ) { bypassMode = !bypassMode; longPressHandled = true; bypassChanged = true; markSettingsChanged(); } } // ===================================================== // FACTORY DEFAULT // // Restores the original startup state: // BAND 1 // Frequencies: 80, 250, 1250, 5000, 12000 Hz // Gains : all 0 dB // Q : 1.00, 1.00, 1.40, 1.00, 1.00 // Speed : 1x // All Bands : OFF // Bypass : OFF // ===================================================== void restoreFactoryDefaults() { selectedBand = 1; bandFreq[0] = 80.0; bandFreq[1] = 250.0; bandFreq[2] = 1250.0; bandFreq[3] = 5000.0; bandFreq[4] = 12000.0; bandGain[0] = 0.0; bandGain[1] = 0.0; bandGain[2] = 0.0; bandGain[3] = 0.0; bandGain[4] = 0.0; bandQ[0] = 1.00; bandQ[1] = 1.00; bandQ[2] = 1.40; bandQ[3] = 1.00; bandQ[4] = 1.00; speed2x = false; allBandsView = false; bypassMode = false; // Factory reset should be stored immediately. saveSettings(); settingsDirty = false; } // ===================================================== // SW2 SHORT / LONG PRESS HANDLER // // Short press : toggle 1x / 2x // Long press : FACTORY DEFAULT // ===================================================== void handleSW2( bool &factoryResetChanged ) { static bool lastRawState = HIGH; static bool stableState = HIGH; static unsigned long lastRawChange = 0; static unsigned long pressStart = 0; static bool longPressHandled = false; unsigned long now = millis(); bool rawState = digitalRead(ENC2_SW); // Raw-state change -> start debounce timer if (rawState != lastRawState) { lastRawState = rawState; lastRawChange = now; } // Accept new stable state after debounce if ( (now - lastRawChange >= SW2_DEBOUNCE_MS) && (stableState != rawState) ) { stableState = rawState; // Button became pressed if (stableState == LOW) { pressStart = now; longPressHandled = false; } // Button became released else { // Short press only if long press was not executed if (!longPressHandled) { speed2x = !speed2x; drawSpeedIndicator(); markSettingsChanged(); } } } // Long press while button is still held if ( stableState == LOW && !longPressHandled && (now - pressStart >= SW2_LONG_PRESS_MS) ) { restoreFactoryDefaults(); longPressHandled = true; factoryResetChanged = true; } } // ===================================================== // SW3 SHORT / LONG PRESS + PRESET MODE // // NORMAL MODE: // Short press : SINGLE / ALL graph // Long press : enter PRESET MODE // // PRESET MODE: // Q encoder : select P1..P5 // Short press : LOAD selected preset // Long press : SAVE current EQ into selected preset // ===================================================== void handleSW3( bool &graphViewChanged, bool &presetVisualChanged, bool &presetLoadedChanged ) { static bool lastRawState = HIGH; static bool stableState = HIGH; static unsigned long lastRawChange = 0; static unsigned long pressStart = 0; static bool longPressHandled = false; static bool pressStartedInPresetMode = false; unsigned long now = millis(); bool rawState = digitalRead(ENC3_SW); if (rawState != lastRawState) { lastRawState = rawState; lastRawChange = now; } if ( (now - lastRawChange >= SW3_DEBOUNCE_MS) && (stableState != rawState) ) { stableState = rawState; // Button pressed if (stableState == LOW) { pressStart = now; longPressHandled = false; pressStartedInPresetMode = presetMode; if (presetMode) { presetLastActivity = now; } } // Button released -> short click if no long action happened else { if (!longPressHandled) { if (pressStartedInPresetMode) { // ------------------------------- // PRESET MODE SHORT = LOAD // ------------------------------- if (loadPresetSlot(presetSelectedSlot)) { lastPresetSlot = presetSelectedSlot; presetMode = false; setPresetStatus( String("P") + String(presetSelectedSlot) + " LOADED", COL_GREEN ); presetLoadedChanged = true; presetVisualChanged = true; // Loaded values become the current power-off state too. markSettingsChanged(); } else { // Empty slot: stay in PRESET MODE so another slot can be selected. setPresetStatus( String("P") + String(presetSelectedSlot) + " EMPTY", COL_YELLOW ); presetLastActivity = now; presetVisualChanged = true; } } else { // ------------------------------- // NORMAL MODE SHORT = SINGLE/ALL // ------------------------------- allBandsView = !allBandsView; graphViewChanged = true; markSettingsChanged(); } } } } // Long press while still held if ( stableState == LOW && !longPressHandled && (now - pressStart >= SW3_LONG_PRESS_MS) ) { longPressHandled = true; if (pressStartedInPresetMode) { // ----------------------------------- // PRESET MODE LONG = SAVE // ----------------------------------- if (savePresetSlot(presetSelectedSlot)) { lastPresetSlot = presetSelectedSlot; presetMode = false; setPresetStatus( String("P") + String(presetSelectedSlot) + " SAVED", COL_GREEN ); markSettingsChanged(); } else { setPresetStatus( "SAVE ERROR", COL_RED ); presetMode = false; } presetVisualChanged = true; } else { // ----------------------------------- // NORMAL MODE LONG = ENTER PRESETS // ----------------------------------- presetMode = true; presetSelectedSlot = lastPresetSlot; presetLastActivity = now; clearPresetStatus(); presetVisualChanged = true; } } } // PRESET mode auto-cancels after inactivity. // Transient LOADED/SAVED/EMPTY/CANCEL messages are also expired here. void updatePresetTimers( bool &presetVisualChanged ) { unsigned long now = millis(); if ( presetMode && (now - presetLastActivity >= PRESET_MODE_TIMEOUT_MS) ) { presetMode = false; setPresetStatus( "PRESET CANCEL", COL_GREY ); presetVisualChanged = true; } if ( presetStatusText.length() > 0 && !presetStatusActive() ) { clearPresetStatus(); presetVisualChanged = true; } } // ===================================================== // REAL AUDIO DSP HELPERS // ===================================================== void resetDSPStates() { for (int i = 0; i < 5; i++) { dspStateCH0[i].z1 = 0.0f; dspStateCH0[i].z2 = 0.0f; dspStateCH1[i].z1 = 0.0f; dspStateCH1[i].z2 = 0.0f; } } // Copy the UI values into a small protected staging area. // Coefficients are calculated later inside the dedicated audio task. void queueDSPSettingsFromUI(bool resetStates) { portENTER_CRITICAL(&dspSettingsMux); for (int i = 0; i < 5; i++) { pendingBandFreq[i] = bandFreq[i]; pendingBandGain[i] = bandGain[i]; pendingBandQ[i] = bandQ[i]; } dspBypassMode = bypassMode; dspSettingsDirty = true; if (resetStates) { dspResetRequested = true; } portEXIT_CRITICAL(&dspSettingsMux); } void queueDSPBypassFromUI() { portENTER_CRITICAL(&dspSettingsMux); dspBypassMode = bypassMode; // Clearing old biquad memory prevents a stale transient // when leaving BYPASS and returning to EQ processing. dspResetRequested = true; portEXIT_CRITICAL(&dspSettingsMux); } void calculateDSPPeakingEQ( int band, float freqHz, float gainDb, float q ) { // At exactly 0 dB use an exact identity section. // This keeps a flat band mathematically transparent. if (fabsf(gainDb) < 0.00001f) { dspCoeff[band].b0 = 1.0f; dspCoeff[band].b1 = 0.0f; dspCoeff[band].b2 = 0.0f; dspCoeff[band].a1 = 0.0f; dspCoeff[band].a2 = 0.0f; return; } float A = powf( 10.0f, gainDb / 40.0f ); float omega = 2.0f * PI * freqHz / (float)AUDIO_SAMPLE_RATE; float sinOmega = sinf(omega); float cosOmega = cosf(omega); float alpha = sinOmega / (2.0f * q); float b0 = 1.0f + alpha * A; float b1 = -2.0f * cosOmega; float b2 = 1.0f - alpha * A; float a0 = 1.0f + alpha / A; float a1 = -2.0f * cosOmega; float a2 = 1.0f - alpha / A; dspCoeff[band].b0 = b0 / a0; dspCoeff[band].b1 = b1 / a0; dspCoeff[band].b2 = b2 / a0; dspCoeff[band].a1 = a1 / a0; dspCoeff[band].a2 = a2 / a0; } void applyPendingDSPSettings() { bool updateCoefficients = false; bool resetStates = false; float localFreq[5]; float localGain[5]; float localQ[5]; portENTER_CRITICAL(&dspSettingsMux); if (dspSettingsDirty) { for (int i = 0; i < 5; i++) { localFreq[i] = pendingBandFreq[i]; localGain[i] = pendingBandGain[i]; localQ[i] = pendingBandQ[i]; } dspSettingsDirty = false; updateCoefficients = true; } if (dspResetRequested) { dspResetRequested = false; resetStates = true; } portEXIT_CRITICAL(&dspSettingsMux); if (updateCoefficients) { for (int i = 0; i < 5; i++) { calculateDSPPeakingEQ( i, localFreq[i], localGain[i], localQ[i] ); } } if (resetStates) { resetDSPStates(); } } float processDSPBiquad( float x, const BiquadCoeff &c, BiquadState &state ) { // Transposed Direct Form II float y = c.b0 * x + state.z1; state.z1 = c.b1 * x - c.a1 * y + state.z2; state.z2 = c.b2 * x - c.a2 * y; return y; } float processDSPFiveBands( float x, BiquadState state[] ) { float y = x; for (int band = 0; band < 5; band++) { y = processDSPBiquad( y, dspCoeff[band], state[band] ); } return y; } int16_t dspFloatToInt16(float x) { if (x >= 1.0f) return 32767; if (x <= -1.0f) return -32768; return (int16_t)( x * 32767.0f ); } void processRealStereoDSP(size_t bytes) { // Real BYPASS = untouched ADC samples go directly back to DAC. if (dspBypassMode) { return; } int sampleCount = bytes / sizeof(int16_t); bool blockClipped = false; for (int i = 0; i + 1 < sampleCount; i += 2) { float x0 = ((float)audioBuffer[i] / 32768.0f) * DSP_PREAMP_GAIN; float x1 = ((float)audioBuffer[i + 1] / 32768.0f) * DSP_PREAMP_GAIN; float y0 = processDSPFiveBands( x0, dspStateCH0 ); float y1 = processDSPFiveBands( x1, dspStateCH1 ); if ( y0 >= 1.0f || y0 <= -1.0f || y1 >= 1.0f || y1 <= -1.0f ) { blockClipped = true; } audioBuffer[i] = dspFloatToInt16(y0); audioBuffer[i + 1] = dspFloatToInt16(y1); } if (blockClipped) { // 500 ms latch reserved for the later red graph indicator. dspClipUntil = millis() + 500; } } // ===================================================== // WM8960 CONFIGURATION // Proven working configuration from TEST 3C / 4A / 4B // ===================================================== #define CODEC_CHECK(label, command) \ do \ { \ bool ok = false; \ int attempt = 0; \ \ for (attempt = 1; attempt <= 3; attempt++) \ { \ if (command) \ { \ ok = true; \ break; \ } \ \ delay(30); \ } \ \ Serial.print(label); \ Serial.print(" : "); \ \ if (ok) \ { \ Serial.print("OK"); \ \ if (attempt > 1) \ { \ Serial.print(" attempt "); \ Serial.print(attempt); \ } \ \ Serial.println(); \ } \ else \ { \ Serial.println("FAILED"); \ return false; \ } \ \ delay(10); \ } while (0) bool setupAudioCodec() { Serial.println(); Serial.println("======================================"); Serial.println("WM8960: CONFIGURE CODEC FIRST"); Serial.println("I2S CLOCKS ARE STILL STOPPED"); Serial.println("======================================"); Wire.begin( WM_SDA, WM_SCL ); // Proven stable by the earlier 500/500 I2C test. Wire.setClock(25000); delay(1000); bool found = false; for (int attempt = 1; attempt <= 10; attempt++) { Serial.print("codec.begin attempt "); Serial.print(attempt); Serial.print(" : "); if (codec.begin()) { Serial.println("OK"); found = true; break; } Serial.println("FAILED"); delay(100); } if (!found) return false; delay(200); CODEC_CHECK( "VREF", codec.enableVREF() ); CODEC_CHECK( "VMID", codec.enableVMID() ); delay(200); // 24 MHz onboard oscillator -> proven 48 kHz clock setup CODEC_CHECK( "PLL prescale /2", codec.setPLLPRESCALE( WM8960_PLLPRESCALE_DIV_2 ) ); CODEC_CHECK( "PLL fractional", codec.setSMD( WM8960_PLL_MODE_FRACTIONAL ) ); CODEC_CHECK( "PLL N=8", codec.setPLLN(8) ); CODEC_CHECK( "PLL K", codec.setPLLK( 0x31, 0x26, 0xE8 ) ); CODEC_CHECK( "PLL ON", codec.enablePLL() ); delay(100); CODEC_CHECK( "Clock source PLL", codec.setCLKSEL( WM8960_CLKSEL_PLL ) ); CODEC_CHECK( "SYSCLK /2", codec.setSYSCLKDIV( WM8960_SYSCLK_DIV_BY_2 ) ); CODEC_CHECK( "ADC divider 256fs", codec.setADCDIV(0) ); CODEC_CHECK( "DAC divider 256fs", codec.setDACDIV(0) ); CODEC_CHECK( "16-bit I2S", codec.setWL( WM8960_WL_16BIT ) ); CODEC_CHECK( "WM8960 SLAVE", codec.enablePeripheralMode() ); CODEC_CHECK( "LR polarity normal", codec.setLRP( WM8960_LR_POLARITY_NORMAL ) ); CODEC_CHECK( "ADC L/R swap OFF", codec.setALRSWAP( WM8960_ALRSWAP_NORMAL ) ); CODEC_CHECK( "ADC/DAC common WS", codec.setALRCGPIO() ); // ---------- LINE INPUT ---------- CODEC_CHECK( "LEFT PGA ON", codec.enableLMIC() ); CODEC_CHECK( "RIGHT PGA ON", codec.enableRMIC() ); CODEC_CHECK( "LINPUT1 -> LEFT PGA", codec.connectLMN1() ); CODEC_CHECK( "RINPUT1 -> RIGHT PGA", codec.connectRMN1() ); CODEC_CHECK( "LEFT PGA UNMUTE", codec.disableLINMUTE() ); CODEC_CHECK( "RIGHT PGA UNMUTE", codec.disableRINMUTE() ); CODEC_CHECK( "LEFT PGA DEMO GAIN", codec.setLINVOLDB(LINE_INPUT_PGA_DB) ); CODEC_CHECK( "RIGHT PGA DEMO GAIN", codec.setRINVOLDB(LINE_INPUT_PGA_DB) ); CODEC_CHECK( "LEFT MIC BOOST 0dB", codec.setLMICBOOST( WM8960_MIC_BOOST_GAIN_0DB ) ); CODEC_CHECK( "RIGHT MIC BOOST 0dB", codec.setRMICBOOST( WM8960_MIC_BOOST_GAIN_0DB ) ); CODEC_CHECK( "LEFT PGA -> ADC mixer", codec.connectLMIC2B() ); CODEC_CHECK( "RIGHT PGA -> ADC mixer", codec.connectRMIC2B() ); CODEC_CHECK( "LEFT analog input ON", codec.enableAINL() ); CODEC_CHECK( "RIGHT analog input ON", codec.enableAINR() ); // ---------- ADC ---------- CODEC_CHECK( "LEFT ADC ON", codec.enableAdcLeft() ); CODEC_CHECK( "RIGHT ADC ON", codec.enableAdcRight() ); CODEC_CHECK( "LEFT ADC 0dB", codec.setAdcLeftDigitalVolumeDB(0.0) ); CODEC_CHECK( "RIGHT ADC 0dB", codec.setAdcRightDigitalVolumeDB(0.0) ); // ---------- DAC ---------- CODEC_CHECK( "DAC LEFT ON", codec.enableDacLeft() ); CODEC_CHECK( "DAC RIGHT ON", codec.enableDacRight() ); CODEC_CHECK( "DAC LEFT 0dB", codec.setDacLeftDigitalVolume(255) ); CODEC_CHECK( "DAC RIGHT 0dB", codec.setDacRightDigitalVolume(255) ); CODEC_CHECK( "Digital loopback OFF", codec.disableLoopBack() ); CODEC_CHECK( "LEFT DAC -> LEFT MIX", codec.enableLD2LO() ); CODEC_CHECK( "RIGHT DAC -> RIGHT MIX", codec.enableRD2RO() ); CODEC_CHECK( "LEFT OUTPUT MIX ON", codec.enableLOMIX() ); CODEC_CHECK( "RIGHT OUTPUT MIX ON", codec.enableROMIX() ); CODEC_CHECK( "LEFT analog bypass OFF", codec.disableLB2LO() ); CODEC_CHECK( "RIGHT analog bypass OFF", codec.disableRB2RO() ); CODEC_CHECK( "LEFT HEADPHONE ON", codec.enableLeftHeadphone() ); CODEC_CHECK( "RIGHT HEADPHONE ON", codec.enableRightHeadphone() ); CODEC_CHECK( "HEADPHONE 0dB", codec.setHeadphoneVolumeDB(0.0) ); CODEC_CHECK( "DAC UNMUTE", codec.disableDacMute() ); delay(300); Serial.println("WM8960 CONFIG COMPLETE."); Serial.println(); return true; } bool setupAudioI2S() { Serial.println(); Serial.println("======================================"); Serial.println("START ESP32 FULL DUPLEX I2S LAST"); Serial.println("======================================"); i2s_config_t cfg = {}; cfg.mode = (i2s_mode_t)( I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX ); cfg.sample_rate = AUDIO_SAMPLE_RATE; cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; cfg.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1; cfg.dma_buf_count = 8; cfg.dma_buf_len = AUDIO_FRAMES; cfg.use_apll = false; cfg.tx_desc_auto_clear = true; cfg.fixed_mclk = 0; esp_err_t err = i2s_driver_install( AUDIO_I2S, &cfg, 0, NULL ); Serial.print("i2s_driver_install = "); Serial.println(err); if (err != ESP_OK) return false; i2s_pin_config_t pins = {}; pins.mck_io_num = I2S_PIN_NO_CHANGE; pins.bck_io_num = I2S_BCLK; pins.ws_io_num = I2S_WS; pins.data_out_num = I2S_DOUT; pins.data_in_num = I2S_DIN; err = i2s_set_pin( AUDIO_I2S, &pins ); Serial.print("i2s_set_pin = "); Serial.println(err); if (err != ESP_OK) return false; i2s_zero_dma_buffer( AUDIO_I2S ); delay(200); Serial.println("FULL DUPLEX I2S READY."); Serial.println(); return true; } // ===================================================== // DEDICATED AUDIO TASK // // Runs on Core 0 so TFT redraws / encoders / NVS in the // normal Arduino loop cannot starve the real-time audio path. // ===================================================== void audioTask(void *parameter) { applyPendingDSPSettings(); for (;;) { applyPendingDSPSettings(); size_t bytesRead = 0; esp_err_t readErr = i2s_read( AUDIO_I2S, audioBuffer, sizeof(audioBuffer), &bytesRead, portMAX_DELAY ); if ( readErr == ESP_OK && bytesRead > 0 ) { processRealStereoDSP( bytesRead ); size_t bytesWritten = 0; i2s_write( AUDIO_I2S, audioBuffer, bytesRead, &bytesWritten, portMAX_DELAY ); } } } // ===================================================== // SETUP // ===================================================== void setup() { Serial.begin(115200); delay(500); Serial.println(); Serial.println("======================================"); Serial.println("5-BAND PARAMETRIC EQ + REAL AUDIO DSP"); Serial.println("UI V1.4 + WM8960 + 5 BIQUADS / CHANNEL + PRESETS"); Serial.println("======================================"); // =================================================== // ENCODER INPUTS // =================================================== // GPIO34/35/36/39: // external 10k pullups already on hardware pinMode( ENC1_A, INPUT ); pinMode( ENC1_B, INPUT ); pinMode( ENC2_A, INPUT ); pinMode( ENC2_B, INPUT ); pinMode( ENC3_A, INPUT ); pinMode( ENC3_B, INPUT ); // Buttons to GND pinMode( ENC1_SW, INPUT_PULLUP ); pinMode( ENC2_SW, INPUT_PULLUP ); pinMode( ENC3_SW, INPUT_PULLUP ); // =================================================== // INITIAL ENCODER STATES // =================================================== enc1.lastAB = ( digitalRead(ENC1_A) << 1 ) | digitalRead(ENC1_B); enc2.lastAB = ( digitalRead(ENC2_A) << 1 ) | digitalRead(ENC2_B); enc3.lastAB = ( digitalRead(ENC3_A) << 1 ) | digitalRead(ENC3_B); // =================================================== // TFT // =================================================== tft.init(); tft.setRotation(1); // =================================================== // CREATE GRAPH SPRITE // ~62 kB // =================================================== graphSprite.setColorDepth( 16 ); graphSprite.createSprite( GRAPH_W, GRAPH_H ); // =================================================== // LOAD LAST SAVED SETTINGS // =================================================== loadSettings(); // =================================================== // INITIAL SCREEN // =================================================== tft.fillScreen( COL_BG ); drawHeader(); drawLeftPanel(); drawGraphLabels(); renderGraph(); drawSpeedIndicator(); drawBandButtons(); // =================================================== // INITIAL REAL DSP SETTINGS // =================================================== queueDSPSettingsFromUI(true); // =================================================== // AUDIO STARTUP // WM8960 FIRST, I2S CLOCKS LAST // =================================================== if (!setupAudioCodec()) { Serial.println("AUDIO CODEC SETUP FAILED"); while (1) delay(1000); } if (!setupAudioI2S()) { Serial.println("AUDIO I2S SETUP FAILED"); while (1) delay(1000); } // Dedicated real-time audio processing on Core 0. BaseType_t taskResult = xTaskCreatePinnedToCore( audioTask, "AudioDSP", 8192, NULL, 3, &audioTaskHandle, 0 ); if (taskResult != pdPASS) { Serial.println("AUDIO TASK CREATE FAILED"); while (1) delay(1000); } Serial.println("REAL 5-BAND STEREO DSP RUNNING."); Serial.print("LINE INPUT PGA (CLIP DEMO) = +"); Serial.print(LINE_INPUT_PGA_DB, 1); Serial.println(" dB"); Serial.println("DSP CLIP WARNING = graph turns RED for 500 ms"); Serial.println("SW3 LONG = PRESET MODE"); Serial.println("PRESET MODE: Q rotate=P1..P5, SW3 short=LOAD, SW3 long=SAVE"); Serial.println(); } // ===================================================== // LOOP // ===================================================== void loop() { bool parametersChanged = false; bool bandChanged = false; bool bypassChanged = false; bool factoryResetChanged = false; bool graphViewChanged = false; bool presetVisualChanged = false; bool presetLoadedChanged = false; // Expire PRESET mode / transient preset messages. updatePresetTimers( presetVisualChanged ); // =================================================== // ROTARY 1 - FREQUENCY // =================================================== int e1 = readEncoder(enc1); if (e1 != 0 && !bypassMode) { changeFrequency( e1 * ENC1_DIR ); parametersChanged = true; markSettingsChanged(); } // =================================================== // ROTARY 2 - GAIN // =================================================== int e2 = readEncoder(enc2); if (e2 != 0 && !bypassMode) { changeGain( e2 * ENC2_DIR ); parametersChanged = true; markSettingsChanged(); } // =================================================== // ROTARY 3 - Q // =================================================== int e3 = readEncoder(enc3); if (e3 != 0) { if (presetMode) { // In PRESET MODE the Q encoder temporarily selects P1..P5. int direction = e3 * ENC3_DIR; presetSelectedSlot += direction; if (presetSelectedSlot > PRESET_COUNT) presetSelectedSlot = 1; if (presetSelectedSlot < 1) presetSelectedSlot = PRESET_COUNT; presetLastActivity = millis(); // If an EMPTY message was being shown, immediately return // to the live PRESET Pn selection display. clearPresetStatus(); presetVisualChanged = true; } else if (!bypassMode) { changeQ( e3 * ENC3_DIR ); parametersChanged = true; markSettingsChanged(); } } // Send changed Frequency / Gain / Q values to the // real audio task immediately, independently of TFT redraw. if (parametersChanged) { queueDSPSettingsFromUI(false); } // =================================================== // SW1 // SHORT PRESS = NEXT BAND // LONG PRESS = BYPASS ON / OFF // =================================================== handleSW1( bandChanged, bypassChanged ); if (bypassChanged) { // The existing UI BYPASS now controls the real DSP path. queueDSPBypassFromUI(); } // =================================================== // SW2 // SHORT PRESS = TOGGLE 1x / 2x // LONG PRESS = FACTORY DEFAULT // =================================================== handleSW2( factoryResetChanged ); if (factoryResetChanged) { // Factory defaults must update the real coefficients and bypass state too. queueDSPSettingsFromUI(true); } // =================================================== // SW3 // NORMAL : SHORT = SINGLE/ALL, LONG = PRESET MODE // PRESET : SHORT = LOAD, LONG = SAVE // =================================================== handleSW3( graphViewChanged, presetVisualChanged, presetLoadedChanged ); // A loaded preset changes all 15 real EQ parameters. if (presetLoadedChanged) { queueDSPSettingsFromUI(true); drawLeftPanel(); drawBandButtons(); } // Normal-mode short SW3 still performs exactly the old // SINGLE / ALL graph function. if (graphViewChanged) { renderGraph(); // Update red ALL-BANDS indicator drawSpeedIndicator(); } // PRESET selection/status text lives inside the graph. if (presetVisualChanged) { renderGraph(); } // =================================================== // FACTORY DEFAULT CHANGE // =================================================== if (factoryResetChanged) { drawLeftPanel(); renderGraph(); drawSpeedIndicator(); drawBandButtons(); } // =================================================== // BYPASS CHANGE // =================================================== if (bypassChanged) { // BYPASS ON -> flat 0 dB graph // BYPASS OFF -> restore the exact previously set EQ curve renderGraph(); // Update B bypass indicator drawSpeedIndicator(); } // =================================================== // PARAMETER CHANGE // =================================================== if (parametersChanged) { // Values in left panel drawLeftPanel(); // Graph: // selected band in SINGLE mode, // overall response in ALL mode renderGraph(); } // =================================================== // BAND SELECTION CHANGE // =================================================== if (bandChanged) { drawLeftPanel(); drawBandButtons(); // In SINGLE mode graph must switch // to the newly selected band. // // In ALL mode the combined curve is // unchanged just by selecting another band. if (!allBandsView) { renderGraph(); } } // =================================================== // REAL-TIME DSP CLIPPING GRAPH INDICATOR // =================================================== // The audio task latches dspClipUntil for 500 ms whenever // either processed channel exceeds digital full scale. // Redraw only when CLIP state changes, avoiding continuous TFT work. bool clipNow = isDSPClippingActive(); if (clipNow != clipGraphShown) { clipGraphShown = clipNow; renderGraph(); } // Save 1 second after the last change saveSettingsIfNeeded(); }